SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
7.0 KB · 137 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import { notFound, permanentRedirect } from 'next/navigation';3import { Suspense } from 'react';4import { SectionSkeleton } from '@/components/satellite/skeleton';5import { Hero, TelemetryPanel } from '@/components/satellite/hero';6import { LiveMap } from '@/components/satellite/live-map';7import { Block, Head, Note } from '@/components/satellite/primitives';8import { EventsSection, HistorySection, IdentifiersSection, LaunchSection, MissionSection, OrbitSection, OwnershipSection, RegistrationSection, RelatedSection, SourcesSection } from '@/components/satellite/sections';9import { ViewBeacon } from '@/components/satellite/view-beacon';10import { Container } from '@/components/ui/section';11import { api, ApiError, safe } from '@/lib/api';12import { fmtDate, fmtInt } from '@/lib/format';13import { MISSION_LABELS, OBJECT_TYPE_LABELS, routes, SITE_NAME, SITE_URL } from '@/lib/site';14import type { SatelliteDetail } from '@/lib/types';1516type Params = { params: Promise<{ slug: string }> };1718async function load(slug: string): Promise<SatelliteDetail> {19  try {20    const res = await api.satellite(slug);21    return res.data;22  } catch (e) {23    if (e instanceof ApiError && e.notFound) notFound();24    throw e;25  }26}2728function describe(d: SatelliteDetail): string {29  const bits: string[] = [];30  bits.push(`${d.name} (NORAD ${d.norad_id ?? '—'}${d.cospar_id ? `, COSPAR ${d.cospar_id}` : ''}) is a${d.status === 'ACTIVE' ? 'n active' : ` ${d.status.toLowerCase()}`} ${(OBJECT_TYPE_LABELS[d.object_type] ?? d.object_type).toLowerCase()}`);31  if (d.orbit_class) bits.push(`in ${d.orbit_class}`);32  if (d.operator_name) bits.push(`operated by ${d.operator_name}`);33  if (d.launch_date) bits.push(`launched ${fmtDate(d.launch_date)}${d.launch_site_name ? ` from ${d.launch_site_name}` : ''}`);34  let s = bits.join(' ') + '.';35  if (d.orbital_state) s += ` Perigee ${fmtInt(d.orbital_state.perigee_km)} km, apogee ${fmtInt(d.orbital_state.apogee_km)} km, inclination ${d.orbital_state.inclination.toFixed(2)}°.`;36  else if (d.decay_date) s += ` Decayed ${fmtDate(d.decay_date)}.`;37  s += ' Live position, orbital elements, history and sources on SatelliteIndex.';38  return s;39}4041export async function generateMetadata({ params }: Params): Promise<Metadata> {42  const { slug } = await params;43  const d = await safe(api.satellite(slug));44  if (!d) return { title: 'Satellite', robots: { index: false } };45  const s = d.data;46  const title = `${s.name} — Live Orbit, NORAD ${s.norad_id ?? '—'}`;47  const description = describe(s);48  const canonical = routes.satellite(s.slug);49  return {50    title,51    description,52    alternates: { canonical },53    openGraph: { title, description, url: `${SITE_URL}${canonical}`, type: 'article', siteName: SITE_NAME },54    twitter: { card: 'summary_large_image', title, description },55  };56}5758/** Streams after the shell: the history endpoint is the slowest call and must never delay the redirect/404 decision. */59async function OrbitWithHistory({ d }: { d: SatelliteDetail }) {60  const history = d.norad_id !== null ? ((await safe(api.satelliteHistory(d.slug)))?.data ?? null) : null;61  return <OrbitSection d={d} history={history} />;62}6364export default async function SatellitePage({ params }: Params) {65  const { slug } = await params;66  const d = await load(slug);67  if (d.redirected_from || d.slug !== slug) permanentRedirect(routes.satellite(d.slug));6869  const ident = String(d.norad_id ?? d.slug);70  const hasElements = d.orbital_state !== null;7172  const jsonLd = {73    '@context': 'https://schema.org',74    '@type': 'Thing',75    name: d.name,76    alternateName: d.aliases.map((a) => a.alias).filter((a) => a !== d.name),77    identifier: [78      d.norad_id !== null ? { '@type': 'PropertyValue', propertyID: 'NORAD', value: String(d.norad_id) } : null,79      d.cospar_id ? { '@type': 'PropertyValue', propertyID: 'COSPAR', value: d.cospar_id } : null,80    ].filter(Boolean),81    url: `${SITE_URL}${routes.satellite(d.slug)}`,82    description: describe(d),83    additionalType: 'https://schema.org/Dataset',84    subjectOf: { '@type': 'Dataset', name: `${d.name} orbital elements`, description: 'Latest two-line element set and derived orbital parameters', license: 'https://celestrak.org/NORAD/documentation/', isAccessibleForFree: true, creator: d.sources.map((s) => ({ '@type': 'Organization', name: s.name })) },85  };8687  return (88    <Container wide>89      <ViewBeacon type="satellite" id={d.id} />90      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />9192      <Hero d={d} />9394      {/* Two-column terminal layout. DOM order = visual order on every breakpoint: live map → telemetry panel → deep sections. */}95      <div className="lg:grid lg:grid-cols-[minmax(0,1fr)_340px] lg:gap-x-10 xl:grid-cols-[minmax(0,1fr)_380px]">96        <Block id="live" className="lg:col-start-1 lg:row-start-1 lg:pt-0">97          <Head eyebrow="Live position" title={hasElements ? 'Ground track & current position' : 'Live position'}>98            {hasElements && <p className="mt-1 text-xs text-ink-3">Propagated client-side every 5 s from the latest element set (SGP4). Accuracy degrades with element age.</p>}99          </Head>100          {hasElements ? (101            <LiveMap ident={ident} name={d.name} initial={d.live && d.live.error == null ? d.live : null} sourceEpoch={d.orbital_state?.epoch ?? null} />102          ) : (103            <Note>104              Live position unavailable — {d.status === 'DECAYED' ? `this object re-entered the atmosphere${d.decay_date ? ` on ${fmtDate(d.decay_date)}` : ''} and is no longer tracked.` : 'no public orbital element set exists for this object (it may be classified, untracked or too small to catalogue).'}105            </Note>106          )}107        </Block>108109        <aside className="lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:self-start lg:pt-0 lg:sticky lg:top-[calc(var(--header-h)+1rem)]" aria-label="Telemetry readout">110          <div className="py-6 lg:py-0">111            <TelemetryPanel d={d} />112          </div>113        </aside>114115        <div className="lg:col-start-1 lg:row-start-2 divide-y divide-[color:var(--rule)]">116          <Suspense fallback={<SectionSkeleton rows={12} chart title="Loading orbital elements" />}>117            <OrbitWithHistory d={d} />118          </Suspense>119          <MissionSection d={d} />120          <OwnershipSection d={d} />121          <LaunchSection d={d} />122          <HistorySection d={d} />123          <RegistrationSection />124          <SourcesSection d={d} />125          <EventsSection d={d} />126          <RelatedSection d={d} />127          <IdentifiersSection d={d} />128        </div>129      </div>130131      <p className="pb-10 pt-4 text-2xs text-ink-3">132        Orbit class, mission type and constellation membership are derived by SatelliteIndex ({MISSION_LABELS[d.mission_type ?? 'unknown'] ?? d.mission_type}) — see the <a className="hover:text-accent" href={routes.methodology()}>methodology</a>. Positions are propagated estimates, not tracking measurements; never use them for conjunction assessment.133      </p>134    </Container>135  );136}137